package org.flexthinker.binding.bean
{
    import flash.events.EventDispatcher;

    import mx.events.PropertyChangeEvent;

    import mx.logging.ILogger;
    import mx.logging.Log;
    import mx.utils.StringUtil;

    import org.flexthinker.binding.bean.event.BindingEvent;
    import org.flexthinker.binding.bean.support.PropertyResolutionError;
    import org.flexthinker.binding.conversion.Converter;
    import org.flexthinker.binding.value.ValueModel;
    import org.flexthinker.binding.value.support.DelegatingPropertyChangeListener;
    import org.flexthinker.binding.value.support.TypeConverter;

    /**
     * <code>Binding</code> is an abstract class that represents the concept of a binding
     * between two properties, typically of two objects, and contains methods for
     * explicitly syncing the values of the two properties. <code>Binding</code> itself
     * does no automatic syncing between property values. Subclasses will typically
     * keep the values in sync according to some strategy.
     *
     * @see org.flexthinker.binding.bean.AutoBinding
     **/
    public class Binding extends EventDispatcher
    {
        private var log:ILogger = Log.getLogger("org.flexthinker.binding.bean.Binding");

        private var _isBound:Boolean = false;
        private var _isManaged:Boolean = false;

        private var sourceObject:Object;
        private var sourceProperty:ValueModel;
        private var targetObject:Object;
        private var targetProperty:ValueModel;

        private var name:String;

        private var bindingListeners:Vector.<BindingListener> = new Vector.<BindingListener>();

        private var converter:Converter;

        /**
         * Create an instance of <code>Binding</code> between two properties of two
         * objects.
         *
         * TBD : should it throw IllegalArgumentException if the source property or target property null ?
         *
         * @param sourceObject the source object
         * @param sourceProperty a property on the source object
         * @param targetObject the target object
         * @param targetProperty a property on the target object
         * @param name a name for the {@code Binding}
         */
        public function Binding(sourceObject:Object, sourceProperty:PropertyHelper, targetObject:Object,
                targetProperty:PropertyHelper, name:String = null):void
        {
            throwIfInvalidArgs(sourceObject, sourceProperty, targetObject, targetProperty);
            this.sourceObject = sourceObject;
            this.sourceProperty = sourceProperty;
            this.targetObject = targetObject;
            this.targetProperty = targetProperty;
            this.name = name;
        }

        /**
         * A name for the Binding.
         * @return
         *      String value.
         */
        public function getName():String
        {
            return name;
        }

        /**
         * Indicated whether the current Binding is bound.
         * If a Binding is already bounded, <code>bind()</code> can't be called.
         * @see #bind
         */
        public function get isBound():Boolean
        {
            return _isBound;
        }

        public function get isManaged():Boolean
        {
            return _isManaged;
        }

        public function addBindingListener(listener:BindingListener):void
        {
            if (listener == null || bindingListeners.indexOf(listener) >= 0)
            {
                return;
            }

            bindingListeners.push(listener);
        }

        public function removeBindingListener(listener:BindingListener):void
        {
            var idx:int = bindingListeners.indexOf(listener);
            if (idx >= 0)
            {
                bindingListeners.splice(idx, 1);
            }
        }

        public function removeAllBindingListeners():void
        {
            bindingListeners = new Vector.<BindingListener>();
        }


        public function bind():void
        {
            throwIfManaged();
            bindUnmanaged();
        }

        public function unbind():void
        {
            throwIfManaged();
            unbindUnmanaged();
        }

        /**
         * Writes from TARGET into SOURCE.
         * <p>
         * Fetches the value of the target property for the target object and sets it
         * as the value of the source property for the source object. First calls
         * <code>targetProperty.getValue()</code>. If the return value from that method
         * represents a failure, this method returns the failure. Otherwise, it calls
         * <code>sourceProperty.setValue()</code> for the source object with the
         * value obtained from the target.
         * </p>
         *
         * @return
         *         the reason for failure if the binding could not be saved, or
         *         <code>null</code> for success
         * @throws Error if the <code>Binding</code> is managed
         * @see #isManaged()
         * @see #refresh
         */
        public function save():SyncFailure
        {
            throwIfManaged();
            return saveUnmanaged();
        }

        public function saveAndNotify():SyncFailure
        {
            return notifyAndReturn(save());
        }

        /**
         * Writes from SOURCE into TARGET.
         * Fetches the value of the source property for the source object and sets it
         * as the value of the target property for the target object. First calls
         * <code>sourceProperty.getValue()</code>.If the return value from that method
         * represents a failure, this method returns the failure. Otherwise, it calls
         * <code>targetProperty.setValue()</code> on the target property for the target object
         * with the value contained from the source.
         *
         * @return
         *      the reason for failure if the binding could not be refreshed,
         *      or <code>null</code> for success.
         * @throws Error if the <code>Binding</code> is managed
         * @see #isManaged()
         * @see #save
         */
        public function refresh():SyncFailure
        {
            throwIfManaged();
            return refreshUnmanaged();
        }

        public function refreshAndNotify():SyncFailure
        {
            return notifyAndReturn(refresh());
        }

        /**
         * Sets a converter for the Binding.
         * <p>
         * This method may not be called on a bound binding.
         * <p>
         * @param value
         *          The Converter to be used
         *
         * @see org.flexthinker.binding.conversion.Converter
         */
        public function setConverter(value:Converter):void
        {
            throwIfBound();
            this.converter = value;
            converterChanged();
        }


        public function getConverter():Converter
        {
            return converter;
        }


        // -------------------
        //   internal methods
        // -------------------

        private function throwIfInvalidArgs(sourceObject:Object, sourceProperty:PropertyHelper,
                targetObject:Object, targetProperty:PropertyHelper):void
        {
            if (sourceObject == null || sourceProperty == null ||
                    targetObject == null || targetProperty == null)
            {
                throw new ArgumentError(
                        StringUtil.substitute("Binding can't be initialized with null values." +
                                "sourceObject={0}, sourceProperty={1}, targetObject={2}, targetProperty={3}",
                                sourceObject, sourceProperty, targetObject, targetProperty));
            }
        }

        protected function throwIfManaged():void
        {
            if (_isManaged)
            {
                throw new Error("Binding is already managed.")
            }
        }

        protected function throwIfBound():void
        {
            if (_isBound)
            {
                throw new Error("Can not call this method on a bound binding");
            }
        }

        protected function throwIfUnbound():void
        {
            if (!_isBound)
            {
                throw new Error("Can not call this method on an unbound binding");
            }
        }

        protected function bindUnmanaged():void
        {
            throwIfBound();

            sourceProperty.addValueChangeListener(new DelegatingPropertyChangeListener(sourceChanged));
            targetProperty.addValueChangeListener(new DelegatingPropertyChangeListener(targetChanged));

            sourceProperty.setSource(sourceObject);
            targetProperty.setSource(targetObject);

            bindImpl();

            // NOTE: BOUND is still dispatched even when binding has failed at this point due to a SyncFailure.
            // Even if sync fails for some reason, the sourceProperty and targetProperty are still watched.
            // if we don't inform the listeners then they won't know that the properties are still being watched.
            // based on listeners other classes may decide to call unbind() if there is a SyncFailure

            _isBound = true;
            for each (var listener:BindingListener in bindingListeners)
            {
                listener.bindingBecameBound(this);
            }
            dispatchEvent(new BindingEvent(BindingEvent.BOUND));
        }

        protected function unbindUnmanaged():void
        {
            throwIfUnbound();

            sourceProperty.removeAllListeners();
            targetProperty.removeAllListeners();

            unbindImpl();

            _isBound = false;
            for each (var listener:BindingListener in bindingListeners)
            {
                listener.bindingBecameUnbound(this);
            }

            dispatchEvent(new BindingEvent(BindingEvent.UNBOUND));
        }

        /**
         * Placeholder for superclasses to initiate binding
         */
        protected function bindImpl():void
        {
        }

        /**
         * Placeholder for superclasses to remove binding
         */
        protected function unbindImpl():void
        {
        }

        protected function saveUnmanaged():SyncFailure
        {
            var targetVal:Object;
            try
            {
                targetVal = targetProperty.getValue();
            }
            catch(err:PropertyResolutionError)
            {
                return new SyncFailure(err).ofType(SyncFailureType.TARGET_UNREADABLE);
            }

            try
            {
                sourceProperty.setValue(targetVal);
                log.debug("Wrote value=[{0}] from target=[{1}] into source=[{2}]", targetVal, targetObject, sourceObject );
            }
            catch (err:PropertyResolutionError)
            {
                return new SyncFailure(err).ofType(SyncFailureType.SOURCE_UNWRITEABLE);
            }

            return null;
        }

        protected function refreshUnmanaged():SyncFailure
        {
            var sourceVal:Object;
            try
            {
                sourceVal = sourceProperty.getValue();
            }
            catch(err:PropertyResolutionError)
            {
                return new SyncFailure(err).ofType(SyncFailureType.SOURCE_UNREADABLE);
            }
            try
            {
                targetProperty.setValue(sourceVal);
                log.debug("Wrote value=[{0}] from source=[{1}] into target=[{2}]", sourceVal, sourceObject, targetObject );

            }
            catch(err:PropertyResolutionError)
            {
                // TODO: TBD if we let the SyncFailure figure out the cause of the failure.
                // or we let the conversion error to popup in super classes using this method.
                return new SyncFailure(err).ofType(SyncFailureType.TARGET_UNWRITEABLE);
            }

            return null;
        }

        private function notifyAndReturn(failure:SyncFailure):SyncFailure
        {
            if (failure == null)
            {
                notifySynced();
            }
            else
            {
                notifySyncFailed(failure);
            }

            return failure;
        }

        private function notifySynced():void
        {
            for each (var listener:BindingListener in bindingListeners)
            {
                listener.synced(this);
            }
        }

        private function notifySyncFailed(failure:SyncFailure):void
        {
            for each (var listener:BindingListener in bindingListeners)
            {
                listener.syncFailed(this, failure);
            }
        }

        /**
         * Placeholder for superclasses to detect source changes and perform syncing as appropriate.
         * Called after <code>Binding</code> has notified all <code>BindingListener</code>s that
         * the source has changed.
         * @param event
         *          PropertyChangeEvent
         */
        protected function sourceChangedImpl(event:PropertyChangeEvent):void
        {
        }

        /**
         * Placeholder for superclasses to detect target changes and perform syncing as appropriate.
         * Called after <code>Binding</code> has notified all <code>BindingListener</code>s that
         * the target has changed.
         * @param event
         *          PropertyChangeEvent
         */
        protected function targetChangedImpl(event:PropertyChangeEvent):void
        {
        }

        private function sourceChanged(event:PropertyChangeEvent):void
        {
            for each (var listener:BindingListener in bindingListeners)
            {
                listener.sourceChanged(this, event);
            }
            sourceChangedImpl(event);
        }

        private function targetChanged(event:PropertyChangeEvent):void
        {
            for each (var listener:BindingListener in bindingListeners)
            {
                listener.targetChanged(this, event);
            }
            targetChangedImpl(event);
        }

        protected function converterChanged():void
        {
            if (targetProperty is TypeConverter)
            {
                targetProperty = TypeConverter(targetProperty).getWrappedValueModel();
            }

            targetProperty = new TypeConverter(targetProperty, converter);
        }
    }
}

